今天我想要先拉一個顯示工作事項的列表,這時就要提到常常跟 MVVM 一起提到的 Data Binding 了 。
Data Binding 是一個幫助我們把資料綁定在畫面上的 library ,這樣當資料變更時就可以在畫面上看到變化。
新的 Data Binding 已經可以搭配 LiveData 實現資料綁定,讓 Data Binding 的資料也支援 life-aware 的效果。
接下來就開始實現 Data Binding 吧。
在 build.gradle
加入以下程式碼:
android {
...
dataBinding {
enabled = true
enabledForTests = true // 未來寫測試會用到
}
}
然後重新編譯即可。
昨天在 ViewModel 建立了一個 LiveData dataLoading
,可以表示資料 loading 中的狀態,現在就把它拿來控制我們的 SwipeRefreshLayout
的 loading 圈圈。
先在 TasksViewModel 創建 dataLoading
:
class tasksViewModel @Inject constructor(
......
) : ViewModel() {
private val _dataLoading = MutableLiveData<Boolean>()
val dataLoading: LiveData<Boolean> = _dataLoading
......
}
接著要來調整一下 TasksFragment 的 xml layout ,打開我的 TasksFragment 的 xml tasks_frag.xml
,並在 root layout 外包上一層 layout
標籤,當然像是 xmlns:android
之類的也要移到這裡:
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="android.view.View" />
<variable
name="viewmodel"
type="com.example.android.architecture.blueprints.todoapp.tasks.TasksViewModel" />
</data>
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/coordinatorLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:refreshing="@{viewmodel.dataLoading}">
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>
稍微解釋一下意思:
data
描述所綁定的 variable ,這邊我綁定了 TasksViewModel,如果後續寫 Data Binding 時會用到 Android API ,可以用 import
的方式 import 進來,像是 View.VISIBLE
或是 Context
之類的 Android API 。
最後將 Activity / Fragment 使用的 layout 改成 Data Binding 幫我們生成的 layout ,以tasks_frag.xml
為例, Data Binding 會自動生成一個 Binding class TasksFragBinding
:
class TasksFragment : DaggerFragment() {
private lateinit var viewDataBinding: TasksFragBinding
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
viewDataBinding = TasksFragBinding.inflate(inflater, container, false).apply {
// 綁定資料到 layout 上
viewmodel = viewModel
}
return viewDataBinding.root
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
// 別忘了把 Fragment 的 lifecycle owner 交給 binding class
viewDataBinding.lifecycleOwner = this.viewLifecycleOwner
}
......
}
這樣 TasksFragment 的 UI 就完成 loading 狀態的 Data binding 了。
剛剛示範了簡單的 Data Binding ,可以開始完成今天的目標了。
今天我的目標是完成工作事項的畫面綁定, RecyclerView 具體如何實作我就不提了,直接開始改造 RecyclerView
吧。
一樣先修改 xml 檔:
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="android.widget.CompoundButton" />
<variable
name="task"
type="com.example.android.architecture.blueprints.todoapp.data.Task" />
<variable
name="viewmodel"
type="com.example.android.architecture.blueprints.todoapp.tasks.TasksViewModel" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="?android:attr/listPreferredItemHeight"
android:orientation="horizontal"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingBottom="@dimen/list_item_padding"
android:paddingTop="@dimen/list_item_padding">
<CheckBox
android:id="@+id/complete"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:onClick="@{(view) -> viewmodel.completeTask(task, ((CompoundButton)view).isChecked())}"
android:checked="@{task.completed}" />
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginLeft="@dimen/activity_horizontal_margin"
android:layout_marginStart="@dimen/activity_horizontal_margin"
android:textAppearance="@style/TextAppearance.AppCompat.Title"
android:text="@{task.titleForList}" />
</LinearLayout>
</layout>
定義一個 Task 資料的 variable ,讓 CheckBox 的狀態及文字皆依據 Task
的內容,同時寫一個 click event 在點擊 checkbox 時觸發 ViewModel 的 completeTask()
方法。
接著修改 RecyclerView Adapter :
class TasksAdapter(private val viewModel: TasksViewModel) :
ListAdapter<Task, ViewHolder>(TaskDiffCallback()) {
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = getItem(position)
holder.bind(viewModel, item)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder.from(parent)
}
class ViewHolder private constructor(val binding: TaskItemBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(viewModel: TasksViewModel, item: Task) {
// 綁定資料到 layout 上
binding.viewmodel = viewModel
binding.task = item
binding.executePendingBindings()
}
companion object {
fun from(parent: ViewGroup): ViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val binding = TaskItemBinding.inflate(layoutInflater, parent, false)
return ViewHolder(binding)
}
}
}
}
/**
* 使用了 DiffUtil ,有興趣可以自行理解
*/
class TaskDiffCallback : DiffUtil.ItemCallback<Task>() {
override fun areItemsTheSame(oldItem: Task, newItem: Task): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Task, newItem: Task): Boolean {
return oldItem == newItem
}
}
好了, RecyclerView 的 Data Binding 完成!
可能有人已經發現我沒有實作把資料傳進來的方法,接下來讓我們看看具體實作的方式。
Data Binding 還提供了一些 API 讓我們處理一些狀況,像是有時候我們想要實作綁定資料後具體的畫面行為或是資料變化,像是把要顯示的資料綁在 RecyclerView 上,那麼要怎麼做呢?
這時候就會需要使用 @BindingAdapter
了 ,BindingAdapter
可以讓我們自定義 xml 上一個 attribute 的具體實現,以這個例子的話可以用來定義 RecyclerView 傳值進去的方法。
首先建立一個 extension TasksListBindings
,完成方法如下:
// TasksListBindings.kt
@BindingAdapter("app:items")
fun setItems(listView: RecyclerView, items: List<Task>) {
(listView.adapter as TasksAdapter).submitList(items)
}
我定義了一個 attribute app:items
,用來把要顯示的資料傳給 RecyclerView ,接下來再看看 tasks_frag
內的 RecyclerView 如何使用:
......
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/tasksList"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fadingEdge="vertical"
android:requiresFadingEdge="vertical"
android:fadingEdgeLength="4dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:items="@{viewmodel.items}" />
......
這樣就成功把值傳給 RecyclerView 了,同樣的,如果想要在勾選工作事項時讓被勾選的項目加上一條刪除線,就可以這麼寫:
// TasksListBindings.kt
@BindingAdapter("app:items")
fun setItems(listView: RecyclerView, items: List<Task>) {
(listView.adapter as TasksAdapter).submitList(items)
}
@BindingAdapter("app:completedTask")
fun setStyle(textView: TextView, enable: Boolean) {
if (enable) {
textView.paintFlags = textView.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG
} else {
textView.paintFlags = textView.paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv()
}
}
然後在 item 的 TextView 上:
<TextView
android:id="@+id/itemTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="@dimen/activity_horizontal_margin"
android:layout_marginEnd="@dimen/activity_horizontal_margin"
android:textAppearance="@style/TextAppearance.AppCompat.Title"
android:text="@{task.titleForList}"
app:completedTask="@{task.completed}" />
透過 Task
的 completed
狀態來判斷勾選時要在 item 上畫刪除線。
我簡單介紹了 Data Binding 的使用方式,其實對於 MVVM 模式需不需要使用 Data Binding 有很多看法,個人是抱持推薦的態度,也有人覺得這樣做一個不好就會在 layout 寫了太多畫面邏輯,讓 View 與 ViewModel 之間的耦合性大大增加。
因此要不要用 Data Binding 就見仁見智,我是覺得再如何完美的 library 一旦亂用一樣會有災難性的後果,也不必太過於抗拒,只要用法正確,並了解其優缺點,一樣能好好地使用它。